from __future__ import annotations

import collections
import functools
import http.server
import json
import os
import re
import tempfile
import urllib.parse
from collections.abc import Callable, Iterator
from typing import IO, TYPE_CHECKING, Any

import click

import papis.cli
import papis.config
import papis.logging

if TYPE_CHECKING:
    from papis.document import Document

logger = papis.logging.get_logger(__name__)

USE_GIT = False
TAGS_LIST: dict[str, dict[str, int] | None] = {}


AnyFn = Callable[..., Any]


try:
    # NOTE: the cgi module is being removed in python 3.13, so we add our own
    # little copy of FieldStorage when it's not available
    from cgi import FieldStorage  # type: ignore[import-not-found,unused-ignore]
except ImportError:
    from dataclasses import dataclass, field

    if TYPE_CHECKING:
        from email.message import Message

    @dataclass
    class MiniFieldStorage:
        name: str
        value: str

        def __repr__(self) -> str:
            return f"MiniFieldStorage({self.name!r}, {self.value!r})"

    @dataclass
    class FieldStorage:  # type: ignore[no-redef]
        # NOTE: fields taken from cgi.FieldStorage.__init__
        # https://github.com/python/cpython/blob/3.12/Lib/cgi.py#L330

        fp: IO[bytes] | None = None
        headers: dict[str, str] | Message = field(default_factory=dict)
        outerboundary: bytes = b""
        environ: dict[str, str] = field(default_factory=dict)
        keep_blank_values: bool = False
        strict_parsing: bool = False
        limit: int | None = None
        encoding: str = "utf-8"
        errors: str = "replace"
        max_num_fields: int | None = None
        separator: str = "&"

        def __post_init__(self) -> None:
            self.read_urlencoded()

        @property
        def length(self) -> int:
            return int(self.headers.get("content-length", -1))

        @property
        def qs_on_post(self) -> str | None:
            return None

        def read_urlencoded(self) -> None:
            assert self.fp is not None

            qs_b = self.fp.read(self.length)
            if not isinstance(qs_b, bytes):
                raise ValueError(
                    f"'{self.fp}' should return bytes, got {type(qs_b).__name__}")

            qs = qs_b.decode(self.encoding, self.errors)
            if self.qs_on_post:
                qs += "&" + self.qs_on_post

            query = urllib.parse.parse_qsl(
                qs,
                self.keep_blank_values,
                self.strict_parsing,
                encoding=self.encoding,
                errors=self.errors,
                max_num_fields=self.max_num_fields,
                separator=self.separator)

            self.list = [MiniFieldStorage(key, value) for key, value in query]

        def getvalue(self, name: str) -> str:
            result = [fs for fs in self.list if fs.name == name]
            assert len(result) == 1

            return result[0].value

        def __iter__(self) -> Iterator[str]:
            return iter(self.keys())

        def keys(self) -> list[str]:
            return list({fs.name for fs in self.list})


# Decorators
def redirecting(to: str) -> AnyFn:
    """
    Decorator to redirect http requests easily.
    """
    def wrapper(fn: AnyFn) -> AnyFn:
        def wrapped(self: Any, *args: Any, **kwargs: Any) -> None:
            fn(self, *args, **kwargs)
            self.redirect(to)
        return wrapped
    return wrapper


def ok_html(fun: AnyFn) -> AnyFn:
    """
    Decorator to assert that the response is html code
    """
    def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
        self.send_response(200)
        self.send_header_html()
        self.end_headers()
        return fun(self, *args, **kwargs)
    return wrapped


class PapisRequestHandler(http.server.BaseHTTPRequestHandler):

    """
    The main request handler of the Papis web application.
    """

    def log_message(self, fmt: str, *args: Any) -> None:  # noqa: PLR6301
        logger.info(fmt, *args)

    def _ok(self) -> None:
        self.send_response(200)

    def _header_json(self) -> None:
        self.send_header("Content-Type", "application/json")

    def send_header_html(self) -> None:
        """
        Say that the content sent is html
        """
        self.send_header("Content-Type", "text/html")

    def _send_json(self, data: Any) -> None:
        data = json.dumps(data)
        self.wfile.write(bytes(data, "utf-8"))

    def _send_json_error(self, code: int, msg: str) -> None:
        self.send_response(code)
        self._header_json()
        self.end_headers()
        self._send_json({"message": msg})

    def page_query(self, libname: str, query: str) -> None:
        self._handle_lib(libname)
        cleaned_query = urllib.parse.unquote_plus(query)

        from papis.api import get_documents_in_lib
        docs = get_documents_in_lib(libname, cleaned_query)
        self.page_main(libname, docs, cleaned_query)

    def page_serve_all(self, libname: str) -> None:
        self._handle_lib(libname)

        from papis.api import get_all_documents_in_lib
        docs = get_all_documents_in_lib(libname)
        self.page_main(libname, docs, "All documents")

    def page_main(self,
                  libname: str | None = None,
                  docs: list[Document] | None = None,
                  query: str | None = None) -> None:
        from papis.web.search import QUERY_PLACEHOLDER, html

        if docs is None:
            docs = []

        from papis.api import get_all_documents_in_lib, get_lib_name
        libname = libname or get_lib_name()
        self._handle_lib(libname)

        self.send_response(200)
        self.send_header_html()
        self.end_headers()
        if len(docs) == 0:
            if papis.config.getboolean("serve-empty-query-get-all-documents"):
                docs = get_all_documents_in_lib(libname)

        libfolder = papis.config.get_lib_from_name(libname).paths[0]
        placeholder = QUERY_PLACEHOLDER
        page = html(documents=docs,
                    libname=libname,
                    libfolder=libfolder,
                    pretitle=query or "HOME",
                    query=query or placeholder)
        self.wfile.write(bytes(str(page), "utf-8"))
        self.wfile.flush()

    @ok_html
    @redirecting("/library")
    def clear_cache(self, libname: str) -> None:
        self._handle_lib(libname)

        from papis.database import get_database
        db = get_database(libname)
        db.clear()
        db.initialize()

    @ok_html
    def page_tags(self, libname: str | None = None,
                  sort_by: str | None = None) -> None:
        from papis.api import get_all_documents_in_lib, get_lib_name
        libname = libname or get_lib_name()
        self._handle_lib(libname)
        docs = get_all_documents_in_lib(libname)

        from papis.web.tags import ensure_tags_list, html
        tags_of_tags = [tag
                        for d in docs
                        for tag in ensure_tags_list(d["tags"])]
        if TAGS_LIST.get(libname) is None:
            TAGS_LIST[libname] = collections.defaultdict(int)
            for tag in tags_of_tags:
                TAGS_LIST[libname][tag] += 1  # type: ignore[index]

        page = html(libname=libname,
                    pretitle="TAGS",
                    tags=TAGS_LIST[libname] or {},
                    sort_by=sort_by or "")

        self.wfile.write(bytes(str(page), "utf-8"))
        self.wfile.flush()

    @ok_html
    def page_tags_refresh(self, libname: str | None = None) -> None:
        from papis.api import get_lib_name
        libname = libname or get_lib_name()

        self._handle_lib(libname)
        TAGS_LIST[libname] = None
        self.redirect(f"/library/{libname}/tags")

    @ok_html
    def page_libraries(self) -> None:
        from papis.api import get_lib_name
        libname = get_lib_name()

        from papis.web.libraries import html
        page = html(libname=libname)
        self.wfile.write(bytes(str(page), "utf-8"))
        self.wfile.flush()

    @ok_html
    def page_document(self, libname: str, papis_id: str) -> None:
        from papis.web.docview import html

        doc = self._get_document(libname, papis_id)
        page = html(libname=libname, doc=doc)
        self.wfile.write(bytes(str(page), "utf-8"))
        self.wfile.flush()

    @ok_html
    def fetch_citations(self, libname: str, papis_id: str) -> None:
        doc = self._get_document(libname, papis_id)

        from papis.citations import fetch_and_save_citations
        fetch_and_save_citations(doc)
        self._redirect_back()

    @ok_html
    def fetch_cited_by(self, libname: str, papis_id: str) -> None:
        doc = self._get_document(libname, papis_id)

        from papis.citations import fetch_and_save_cited_by_from_database
        fetch_and_save_cited_by_from_database(doc)
        self._redirect_back()

    def get_libraries(self) -> None:
        logger.info("Getting libraries.")

        from papis.api import get_libraries
        libs = get_libraries()
        logger.debug("Found libraries: '%s'.", "', '".join(libs))

        self._ok()
        self._header_json()
        self.end_headers()
        self._send_json(libs)

    def get_library(self, libname: str) -> None:
        logger.info("Getting library '%s'.", libname)
        lib = papis.config.get_lib_from_name(libname)

        self._ok()
        self._header_json()
        self.end_headers()
        self._send_json({"name": lib.name, "paths": lib.paths})

    def get_all_documents(self, libname: str) -> None:
        self._handle_lib(libname)

        from papis.api import get_all_documents_in_lib
        docs = get_all_documents_in_lib(libname)
        self.serve_documents(docs)

    def get_query(self, libname: str, query: str) -> None:
        self._handle_lib(libname)
        cleaned_query = urllib.parse.unquote(query)
        logger.info("Querying in library '%s' for '%s'.", libname, cleaned_query)

        from papis.api import get_documents_in_lib
        docs = get_documents_in_lib(libname, cleaned_query)
        self.serve_documents(docs)

    def serve_documents(self, docs: list[Document]) -> None:
        """
        Serve a list of documents and set the files attribute to
        the full paths so that the user can reach them.
        """
        logger.info("Serving %s documents.", len(docs))

        # get absolute paths for files
        for d in docs:
            d["files"] = d.get_files()

        self._ok()
        self._header_json()
        self.end_headers()
        self._send_json(docs)

    def redirect(self, url: str, code: int = 301) -> None:
        page = (f"""
                  <head>
                     <meta http-equiv="Refresh" content="0; URL={url}">
                  </head>
                """)
        self.send_response(code)
        self.send_header_html()
        self.send_header("Location", url)
        self.end_headers()
        self.wfile.write(bytes(page, "utf-8"))
        self.wfile.flush()

    def _redirect_back(self) -> None:
        back_url = self.headers.get("Referer", "/library")
        self.redirect(back_url)

    def get_document_format(self, libname: str, query: str, fmt: str) -> None:
        from papis.api import get_documents_in_lib
        docs = get_documents_in_lib(libname, query)

        from papis.commands.export import run as export
        fmts = export(docs, fmt)

        self._ok()
        self._header_json()
        self.end_headers()
        self._send_json(fmts)

    def send_local_document_file(self, libname: str, localpath: str) -> None:
        libfolder = papis.config.get_lib_from_name(libname).paths[0]
        path = os.path.join(libfolder, localpath)
        if os.path.exists(path):
            self._ok()
            self.send_header("Content-Type", "application/pdf")
            self.end_headers()
            with open(path, "rb") as f:
                self.wfile.write(f.read())
            self.wfile.flush()
        else:
            raise FileNotFoundError(f"File '{path}' does not exist")

    def process_routes(self,
                       routes: list[tuple[str, Any]]) -> None:
        """
        Performs the actions of the given routes and dispatches a 404
        page if there is an error.
        """
        try:
            for route, method in routes:
                m = re.match(route, self.path)
                if m:
                    method(*m.groups(), **m.groupdict())
                    return
        except Exception as e:
            self._send_json_error(400, str(e))
        else:
            self._send_json_error(404,
                                  f"Server path {self.path} not understood"
                                  )

    def _handle_lib(self, libname: str) -> None:  # noqa: PLR6301
        from papis.api import set_lib_from_name
        set_lib_from_name(libname)

    def _get_document(self,
                      libname: str,
                      papis_id: str) -> Document:
        self._handle_lib(libname)

        from papis.database import get_database
        db = get_database(libname)
        doc = db.find_by_id(papis_id)
        if not doc:
            raise ValueError(
                f"Document with ref '{papis_id}' not found in the database"
                )
        return doc

    def _get_form(self, method: str = "POST") -> FieldStorage:
        # FIXME: rfile is a BufferedIOBase and fp is a IO[Any]. This seems to be
        # a bug in the type annotations for one of these classes
        return FieldStorage(fp=self.rfile,  # type: ignore[arg-type]
                            headers=self.headers,
                            environ={"REQUEST_METHOD": method})

    def update_notes(self, libname: str, papis_id: str) -> None:
        doc = self._get_document(libname, papis_id)
        form = self._get_form("POST")
        new_notes = form.getvalue("value")

        from papis.notes import notes_path
        path = notes_path(doc)

        with open(path, "w+", encoding="utf-8") as fdr:
            fdr.write(new_notes)
        self._redirect_back()

    def update_info(self, libname: str, papis_id: str) -> None:
        """
        It updates the information by the provided form.

        It first checks that the yaml is readable by using
        yaml_to_data function. If it is successful it supposes
        it is a correct yaml, it overwrites the old yaml
        and updates the database and the document with it.
        """
        doc = self._get_document(libname, papis_id)
        form = self._get_form("POST")
        new_info = form.getvalue("value")
        info_path = doc.get_info_file()

        logger.info("Checking syntax of the info file: '%s'.", info_path)
        with tempfile.NamedTemporaryFile(
                mode="w+", delete=False, encoding="utf-8"
                ) as fdr:
            fdr.write(new_info)

        from papis.yaml import yaml_to_data
        try:
            yaml_to_data(fdr.name, raise_exception=True)
        except ValueError as e:
            self._send_json_error(404, f"Error in info file: {e}")
            os.unlink(fdr.name)
            return
        else:
            os.unlink(fdr.name)

            logger.info("Info file is valid.")
            with open(info_path, "w+", encoding="utf-8") as fd:
                fd.write(new_info)

            doc.load()

            from papis.api import save_doc
            save_doc(doc)

            self._redirect_back()
            return

    def update_page_document(self, libname: str, papis_id: str) -> None:
        doc = self._get_document(libname, papis_id)
        form = self._get_form("POST")

        result = {}
        for key in form:
            if key == "newkey-name":
                newkey = str(form.getvalue("newkey-name"))
                newval = form.getvalue("newkey-value")
                result[newkey] = newval
            elif key == "newkey-value":
                pass
            else:
                result[key] = form.getvalue(key)

        from papis.commands.update import run as update
        update(document=doc, data=result, git=USE_GIT)
        self._redirect_back()

    def serve_static(self, static_path: str, params: str) -> None:
        from papis.web.static import static_paths

        folders = static_paths()
        partial_path = urllib.parse.unquote_plus(static_path)
        for folder in folders:
            path = os.path.join(folder, partial_path)
            if not os.path.exists(path):
                continue

            self._ok()
            if path.endswith("svg"):
                self.send_header("Content-Type", "image/svg+xml")
            elif path.endswith("pdf"):
                self.send_header("Content-Type", "application/pdf")
            elif path.endswith("png"):
                self.send_header("Content-Type", "image/png")
            elif path.endswith("gif"):
                self.send_header("Content-Type", "image/gif")

            self.end_headers()
            with open(path, "rb") as f:
                self.wfile.write(f.read())
                self.wfile.flush()
            return

        raise FileNotFoundError(f"File '{path}' does not exist")

    def do_POST(self) -> None:
        """
        HTTP POST route definitions
        """
        routes = [
            ("^/library/?([^/]+)?/document/([a-z0-9]+)$",
                self.update_page_document),
            ("^/library/?([^/]+)?/document/notes/([a-z0-9]+)$",
                self.update_notes),
            ("^/library/?([^/]+)?/document/info/([a-z0-9]+)$",
                self.update_info),
            ("^/library/?([^/]+)?/document/fetch-citations/([a-z0-9]+)$",
                self.fetch_citations),
            ("^/library/?([^/]+)?/document/fetch-cited-by/([a-z0-9]+)$",
                self.fetch_cited_by),
        ]
        self.process_routes(routes)

    def do_GET(self) -> None:
        """
        HTTP GET route definitions
        """
        routes = [
            # html serving
            ("^/$",
                functools.partial(self.redirect, "/library")),
            ("^/library/?([^/]+)?$",
                self.page_main),
            ("^/libraries$",
                self.page_libraries),
            ("^/library/?([^/]+)?/all$",
                self.page_serve_all),
            ("^/library/?([^/]+)?/query[?]q=(.*)$",
                self.page_query),
            ("^/library/?([^/]+)?/document/([a-z0-9]+)$",
                self.page_document),
            ("^/library/([^/]+)/tags(?:[?]sort=(.*))?$",
                self.page_tags),
            ("^/library/([^/]+)/tags/refresh$",
                self.page_tags_refresh),
            ("^/library/([^/]+)/file/(.+)$",
                self.send_local_document_file),
            ("^/library/([^/]+)/clear_cache$",
                self.clear_cache),
            ("^/static/([^?]*)(.*)$",
                self.serve_static),

            # JSON API
            ("^/api/library$",
                self.get_libraries),
            ("^/api/library/([^/]+)$",
                self.get_library),
            ("^/api/library/([^/]+)/document$",
                self.get_all_documents),
            ("^/api/library/([^/]+)/document/([^/]+)$",
                self.get_query),
            ("^/api/library/([^/]+)/document/([^/]+)/format/([^/]+)$",
                self.get_document_format),
        ]
        self.process_routes(routes)


@click.command("serve")
@click.help_option("-h", "--help")
@click.option("-p", "--port",
              help="Port to listen to",
              default=8888, type=int)
@papis.cli.git_option(help="Add changes made to the info file")
@click.option("--address",
              "--host",
              help="Address to bind",
              default="localhost")
def cli(address: str, port: int, git: bool) -> None:
    """
    Start a papis server
    """
    global USE_GIT
    USE_GIT = git
    server_address = (address, port)

    from papis.web.pdfjs import detect_pdfjs, error_message

    if not detect_pdfjs():
        logger.warning(error_message())

    logger.info("Starting server in address 'http://%s:%s'.",
                address or "localhost",
                port)
    logger.info("Press <Ctrl-C> to exit.")
    logger.info("THIS COMMAND IS EXPERIMENTAL, expect bugs. Feedback appreciated!")

    httpd = http.server.HTTPServer(server_address, PapisRequestHandler)
    httpd.serve_forever()
